<!DOCTYPE html>
<html>

<head>
  <title>moteus Perfomance Analysis Tool</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    body {
      font-size: 14px;
      font-family: system-ui, sans-serif;
      color: #333;
      background: #fcfcfc;
      margin: 0;
      padding: 1 rem;
      padding-bottom: 2.5em;
    }
    table {
      border-collapse: collapse;
      width: 100%;
      table-layout: auto;
    }
    td, th {
      border: none;
      padding: 0.3rem;
      text-align: left;
    }
    th {
      font-weight: 600;
    }
    td {
      border-top: 1px solid #eee;
    }
    td:nth-child(odd) {
      background-color:#f9fafb;
    }

    td:nth-child(even) {
      background-color: #ffffff;
    }

    th:nth-child(odd) {
      background-color: #f1f5f9;
    }
    th:nth-child(even) {
      background-color: #f5f9fa;
    }

    footer {
      position: fixed;
      bottom: 0;
      width: 100%;
      background: #f1f1f1;
      padding: 10px;
      text-align: center;
      font-size: 0.9em;
      border-top: 1px solid #ccc;
    }

    .pageContainer {
      display: flex;
      flex-wrap: wrap;
      gap: 0.2rem;
    }

    .panelColumn {
      flex: 1 1 300px;
      max-width: 400px;
      min-width: 200px;
      padding: 1em;
    }

    .resultsColumn {
      flex: 2 1 500px;
      min-width: 200px;
      padding: 1em;
    }

    .panelContainer {
      border: 1px solid #ddd;
      border-radius: 3px;
      margin: 0.4em 0;
      background: white;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
      overflow: hidden;
    }

    .panelTitle {
      cursor: pointer;
      padding: 0.5rem;
      background:#f7f9fc;
      font-weight: 600;
      border-bottom: 1px solid #eee;
    }

    .resultTable {
      display: inline-block;
      max-width: 100%;
      margin: 1 rem auto;
      background: white;
      border-radius: 5px;
      overflow-x: auto;
      -webkit-overflow-scrolling: touch;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
    }

    .multiHeader {
      font-weight: 600;
    }

    .overlayContainer {
      position: fixed;
      top: 0;
      left: 0;
      width: 100vw;
      height: 100vh;
      background: rgba(0, 0, 0, 0.0);
      z-index: 1000;
      outline: 1px dashed red;
      pointer-events: none;
    }

    .overlayBody {
      position: fixed;
      top: 0;
      right: 0;
      width: 300px;
      height: 100%;
      background: white;
      borderLeft: 1px solid #ccc;
      padding: 1rem;
      box-shadow: -2px 0px 8px rgba(0, 0, 0, 0.2);
      pointer-events: auto;
    }

    @media (max-width: 600px) {
      .overlayBody {
        left: 5px;
        right: 5px;
        top: 5px;
        bottom: 5px;
        border-left: none;
        box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.2);
      }
    }
  </style>
  <script>
    // Embed the contents of preact, preact-hooks, and htm.
    // preact 10.26.5 - MIT License: https://github.com/preactjs/preact/blob/main/LICENSE
    // htm 3.1.1 - Apache 2.0 License: https://github.com/developit/htm/blob/master/LICENSE
    //
    // These were generated by:
    //  curl -L https://unpkg.com/preact@10.26.5/dist/preact.umd.js -o preact.umd.js
    //  curl -L https://unpkg.com/preact@10.26.5/hooks/dist/hooks.umd.js -o preact-hooks.umd.js
    //  curl -L https://unpkg.com/htm@3.1.1/dist/htm.umd.js -o htm.umd.js
    !function(n,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((n||self).preact={})}(this,function(n){var t,i,e,r,f,o,u,c,s,a,h,p,l,y="http://www.w3.org/2000/svg",v="http://www.w3.org/1999/xhtml",d=null,w=void 0,g={},_=[],b=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,m=Array.isArray;function k(n,t){for(var i in t)n[i]=t[i];return n}function x(n){n&&n.parentNode&&n.parentNode.removeChild(n)}function S(n,i,e){var r,f,o,u={};for(o in i)"key"==o?r=i[o]:"ref"==o?f=i[o]:u[o]=i[o];if(arguments.length>2&&(u.children=arguments.length>3?t.call(arguments,2):e),"function"==typeof n&&n.defaultProps!=d)for(o in n.defaultProps)u[o]==w&&(u[o]=n.defaultProps[o]);return M(n,u,r,f,d)}function M(n,t,r,f,o){var u={type:n,props:t,key:r,ref:f,__k:d,__:d,__b:0,__e:d,__c:d,constructor:w,__v:o==d?++e:o,__i:-1,__u:0};return o==d&&i.vnode!=d&&i.vnode(u),u}function T(n){return n.children}function $(n,t){this.props=n,this.context=t}function C(n,t){if(t==d)return n.__?C(n.__,n.__i+1):d;for(var i;t<n.__k.length;t++)if((i=n.__k[t])!=d&&i.__e!=d)return i.__e;return"function"==typeof n.type?C(n):d}function I(n){var t,i;if((n=n.__)!=d&&n.__c!=d){for(n.__e=n.__c.base=d,t=0;t<n.__k.length;t++)if((i=n.__k[t])!=d&&i.__e!=d){n.__e=n.__c.base=i.__e;break}return I(n)}}function P(n){(!n.__d&&(n.__d=!0)&&f.push(n)&&!j.__r++||o!=i.debounceRendering)&&((o=i.debounceRendering)||u)(j)}function j(){for(var n,t,e,r,o,u,s,a=1;f.length;)f.length>a&&f.sort(c),n=f.shift(),a=f.length,n.__d&&(e=void 0,o=(r=(t=n).__v).__e,u=[],s=[],t.__P&&((e=k({},r)).__v=r.__v+1,i.vnode&&i.vnode(e),V(t.__P,e,r,t.__n,t.__P.namespaceURI,32&r.__u?[o]:d,u,o==d?C(r):o,!!(32&r.__u),s),e.__v=r.__v,e.__.__k[e.__i]=e,q(u,e,s),e.__e!=o&&I(e)));j.__r=0}function A(n,t,i,e,r,f,o,u,c,s,a){var h,p,l,y,v,b,m=e&&e.__k||_,k=t.length;for(c=H(i,t,m,c,k),h=0;h<k;h++)(l=i.__k[h])!=d&&(p=-1==l.__i?g:m[l.__i]||g,l.__i=h,b=V(n,l,p,r,f,o,u,c,s,a),y=l.__e,l.ref&&p.ref!=l.ref&&(p.ref&&E(p.ref,d,l),a.push(l.ref,l.__c||y,l)),v==d&&y!=d&&(v=y),4&l.__u||p.__k===l.__k?c=L(l,c,n):"function"==typeof l.type&&b!==w?c=b:y&&(c=y.nextSibling),l.__u&=-7);return i.__e=v,c}function H(n,t,i,e,r){var f,o,u,c,s,a=i.length,h=a,p=0;for(n.__k=new Array(r),f=0;f<r;f++)(o=t[f])!=d&&"boolean"!=typeof o&&"function"!=typeof o?(c=f+p,(o=n.__k[f]="string"==typeof o||"number"==typeof o||"bigint"==typeof o||o.constructor==String?M(d,o,d,d,d):m(o)?M(T,{children:o},d,d,d):o.constructor==w&&o.__b>0?M(o.type,o.props,o.key,o.ref?o.ref:d,o.__v):o).__=n,o.__b=n.__b+1,s=o.__i=F(o,i,c,h),u=d,-1!=s&&(h--,(u=i[s])&&(u.__u|=2)),u==d||u.__v==d?(-1==s&&(r>a?p--:r<a&&p++),"function"!=typeof o.type&&(o.__u|=4)):s!=c&&(s==c-1?p--:s==c+1?p++:(s>c?p--:p++,o.__u|=4))):n.__k[f]=d;if(h)for(f=0;f<a;f++)(u=i[f])!=d&&0==(2&u.__u)&&(u.__e==e&&(e=C(u)),G(u,u));return e}function L(n,t,i){var e,r;if("function"==typeof n.type){for(e=n.__k,r=0;e&&r<e.length;r++)e[r]&&(e[r].__=n,t=L(e[r],t,i));return t}n.__e!=t&&(t&&n.type&&!i.contains(t)&&(t=C(n)),i.insertBefore(n.__e,t||d),t=n.__e);do{t=t&&t.nextSibling}while(t!=d&&8==t.nodeType);return t}function F(n,t,i,e){var r,f,o=n.key,u=n.type,c=t[i];if(c===d&&null==n.key||c&&o==c.key&&u==c.type&&0==(2&c.__u))return i;if(e>(c!=d&&0==(2&c.__u)?1:0))for(r=i-1,f=i+1;r>=0||f<t.length;){if(r>=0){if((c=t[r])&&0==(2&c.__u)&&o==c.key&&u==c.type)return r;r--}if(f<t.length){if((c=t[f])&&0==(2&c.__u)&&o==c.key&&u==c.type)return f;f++}}return-1}function O(n,t,i){"-"==t[0]?n.setProperty(t,i==d?"":i):n[t]=i==d?"":"number"!=typeof i||b.test(t)?i:i+"px"}function z(n,t,i,e,r){var f;n:if("style"==t)if("string"==typeof i)n.style.cssText=i;else{if("string"==typeof e&&(n.style.cssText=e=""),e)for(t in e)i&&t in i||O(n.style,t,"");if(i)for(t in i)e&&i[t]==e[t]||O(n.style,t,i[t])}else if("o"==t[0]&&"n"==t[1])f=t!=(t=t.replace(s,"$1")),t=t.toLowerCase()in n||"onFocusOut"==t||"onFocusIn"==t?t.toLowerCase().slice(2):t.slice(2),n.l||(n.l={}),n.l[t+f]=i,i?e?i.t=e.t:(i.t=a,n.addEventListener(t,f?p:h,f)):n.removeEventListener(t,f?p:h,f);else{if(r==y)t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if("width"!=t&&"height"!=t&&"href"!=t&&"list"!=t&&"form"!=t&&"tabIndex"!=t&&"download"!=t&&"rowSpan"!=t&&"colSpan"!=t&&"role"!=t&&"popover"!=t&&t in n)try{n[t]=i==d?"":i;break n}catch(n){}"function"==typeof i||(i==d||!1===i&&"-"!=t[4]?n.removeAttribute(t):n.setAttribute(t,"popover"==t&&1==i?"":i))}}function N(n){return function(t){if(this.l){var e=this.l[t.type+n];if(t.i==d)t.i=a++;else if(t.i<e.t)return;return e(i.event?i.event(t):t)}}}function V(n,t,e,r,f,o,u,c,s,a){var h,p,l,y,v,g,_,b,S,M,C,I,P,j,H,L,F,O=t.type;if(t.constructor!=w)return d;128&e.__u&&(s=!!(32&e.__u),o=[c=t.__e=e.__e]),(h=i.__b)&&h(t);n:if("function"==typeof O)try{if(b=t.props,S="prototype"in O&&O.prototype.render,M=(h=O.contextType)&&r[h.__c],C=h?M?M.props.value:h.__:r,e.__c?_=(p=t.__c=e.__c).__=p.__E:(S?t.__c=p=new O(b,C):(t.__c=p=new $(b,C),p.constructor=O,p.render=J),M&&M.sub(p),p.props=b,p.state||(p.state={}),p.context=C,p.__n=r,l=p.__d=!0,p.__h=[],p._sb=[]),S&&p.__s==d&&(p.__s=p.state),S&&O.getDerivedStateFromProps!=d&&(p.__s==p.state&&(p.__s=k({},p.__s)),k(p.__s,O.getDerivedStateFromProps(b,p.__s))),y=p.props,v=p.state,p.__v=t,l)S&&O.getDerivedStateFromProps==d&&p.componentWillMount!=d&&p.componentWillMount(),S&&p.componentDidMount!=d&&p.__h.push(p.componentDidMount);else{if(S&&O.getDerivedStateFromProps==d&&b!==y&&p.componentWillReceiveProps!=d&&p.componentWillReceiveProps(b,C),!p.__e&&p.shouldComponentUpdate!=d&&!1===p.shouldComponentUpdate(b,p.__s,C)||t.__v==e.__v){for(t.__v!=e.__v&&(p.props=b,p.state=p.__s,p.__d=!1),t.__e=e.__e,t.__k=e.__k,t.__k.some(function(n){n&&(n.__=t)}),I=0;I<p._sb.length;I++)p.__h.push(p._sb[I]);p._sb=[],p.__h.length&&u.push(p);break n}p.componentWillUpdate!=d&&p.componentWillUpdate(b,p.__s,C),S&&p.componentDidUpdate!=d&&p.__h.push(function(){p.componentDidUpdate(y,v,g)})}if(p.context=C,p.props=b,p.__P=n,p.__e=!1,P=i.__r,j=0,S){for(p.state=p.__s,p.__d=!1,P&&P(t),h=p.render(p.props,p.state,p.context),H=0;H<p._sb.length;H++)p.__h.push(p._sb[H]);p._sb=[]}else do{p.__d=!1,P&&P(t),h=p.render(p.props,p.state,p.context),p.state=p.__s}while(p.__d&&++j<25);p.state=p.__s,p.getChildContext!=d&&(r=k(k({},r),p.getChildContext())),S&&!l&&p.getSnapshotBeforeUpdate!=d&&(g=p.getSnapshotBeforeUpdate(y,v)),L=h,h!=d&&h.type===T&&h.key==d&&(L=B(h.props.children)),c=A(n,m(L)?L:[L],t,e,r,f,o,u,c,s,a),p.base=t.__e,t.__u&=-161,p.__h.length&&u.push(p),_&&(p.__E=p.__=d)}catch(n){if(t.__v=d,s||o!=d)if(n.then){for(t.__u|=s?160:128;c&&8==c.nodeType&&c.nextSibling;)c=c.nextSibling;o[o.indexOf(c)]=d,t.__e=c}else for(F=o.length;F--;)x(o[F]);else t.__e=e.__e,t.__k=e.__k;i.__e(n,t,e)}else o==d&&t.__v==e.__v?(t.__k=e.__k,t.__e=e.__e):c=t.__e=D(e.__e,t,e,r,f,o,u,s,a);return(h=i.diffed)&&h(t),128&t.__u?void 0:c}function q(n,t,e){for(var r=0;r<e.length;r++)E(e[r],e[++r],e[++r]);i.__c&&i.__c(t,n),n.some(function(t){try{n=t.__h,t.__h=[],n.some(function(n){n.call(t)})}catch(n){i.__e(n,t.__v)}})}function B(n){return"object"!=typeof n||n==d||n.__b&&n.__b>0?n:m(n)?n.map(B):k({},n)}function D(n,e,r,f,o,u,c,s,a){var h,p,l,_,b,k,S,M=r.props,T=e.props,$=e.type;if("svg"==$?o=y:"math"==$?o="http://www.w3.org/1998/Math/MathML":o||(o=v),u!=d)for(h=0;h<u.length;h++)if((b=u[h])&&"setAttribute"in b==!!$&&($?b.localName==$:3==b.nodeType)){n=b,u[h]=d;break}if(n==d){if($==d)return document.createTextNode(T);n=document.createElementNS(o,$,T.is&&T),s&&(i.__m&&i.__m(e,u),s=!1),u=d}if($==d)M===T||s&&n.data==T||(n.data=T);else{if(u=u&&t.call(n.childNodes),M=r.props||g,!s&&u!=d)for(M={},h=0;h<n.attributes.length;h++)M[(b=n.attributes[h]).name]=b.value;for(h in M)if(b=M[h],"children"==h);else if("dangerouslySetInnerHTML"==h)l=b;else if(!(h in T)){if("value"==h&&"defaultValue"in T||"checked"==h&&"defaultChecked"in T)continue;z(n,h,d,b,o)}for(h in T)b=T[h],"children"==h?_=b:"dangerouslySetInnerHTML"==h?p=b:"value"==h?k=b:"checked"==h?S=b:s&&"function"!=typeof b||M[h]===b||z(n,h,b,M[h],o);if(p)s||l&&(p.__html==l.__html||p.__html==n.innerHTML)||(n.innerHTML=p.__html),e.__k=[];else if(l&&(n.innerHTML=""),A("template"==e.type?n.content:n,m(_)?_:[_],e,r,f,"foreignObject"==$?v:o,u,c,u?u[0]:r.__k&&C(r,0),s,a),u!=d)for(h=u.length;h--;)x(u[h]);s||(h="value","progress"==$&&k==d?n.removeAttribute("value"):k!=w&&(k!==n[h]||"progress"==$&&!k||"option"==$&&k!=M[h])&&z(n,h,k,M[h],o),h="checked",S!=w&&S!=n[h]&&z(n,h,S,M[h],o))}return n}function E(n,t,e){try{if("function"==typeof n){var r="function"==typeof n.__u;r&&n.__u(),r&&t==d||(n.__u=n(t))}else n.current=t}catch(n){i.__e(n,e)}}function G(n,t,e){var r,f;if(i.unmount&&i.unmount(n),(r=n.ref)&&(r.current&&r.current!=n.__e||E(r,d,t)),(r=n.__c)!=d){if(r.componentWillUnmount)try{r.componentWillUnmount()}catch(n){i.__e(n,t)}r.base=r.__P=d}if(r=n.__k)for(f=0;f<r.length;f++)r[f]&&G(r[f],t,e||"function"!=typeof n.type);e||x(n.__e),n.__c=n.__=n.__e=w}function J(n,t,i){return this.constructor(n,i)}function K(n,e,r){var f,o,u,c;e==document&&(e=document.documentElement),i.__&&i.__(n,e),o=(f="function"==typeof r)?d:r&&r.__k||e.__k,u=[],c=[],V(e,n=(!f&&r||e).__k=S(T,d,[n]),o||g,g,e.namespaceURI,!f&&r?[r]:o?d:e.firstChild?t.call(e.childNodes):d,u,!f&&r?r:o?o.__e:e.firstChild,f,c),q(u,n,c)}t=_.slice,i={__e:function(n,t,i,e){for(var r,f,o;t=t.__;)if((r=t.__c)&&!r.__)try{if((f=r.constructor)&&f.getDerivedStateFromError!=d&&(r.setState(f.getDerivedStateFromError(n)),o=r.__d),r.componentDidCatch!=d&&(r.componentDidCatch(n,e||{}),o=r.__d),o)return r.__E=r}catch(t){n=t}throw n}},e=0,r=function(n){return n!=d&&n.constructor==w},$.prototype.setState=function(n,t){var i;i=this.__s!=d&&this.__s!=this.state?this.__s:this.__s=k({},this.state),"function"==typeof n&&(n=n(k({},i),this.props)),n&&k(i,n),n!=d&&this.__v&&(t&&this._sb.push(t),P(this))},$.prototype.forceUpdate=function(n){this.__v&&(this.__e=!0,n&&this.__h.push(n),P(this))},$.prototype.render=T,f=[],u="function"==typeof Promise?Promise.prototype.then.bind(Promise.resolve()):setTimeout,c=function(n,t){return n.__v.__b-t.__v.__b},j.__r=0,s=/(PointerCapture)$|Capture$/i,a=0,h=N(!1),p=N(!0),l=0,n.Component=$,n.Fragment=T,n.cloneElement=function(n,i,e){var r,f,o,u,c=k({},n.props);for(o in n.type&&n.type.defaultProps&&(u=n.type.defaultProps),i)"key"==o?r=i[o]:"ref"==o?f=i[o]:c[o]=i[o]==w&&u!=w?u[o]:i[o];return arguments.length>2&&(c.children=arguments.length>3?t.call(arguments,2):e),M(n.type,c,r||n.key,f||n.ref,d)},n.createContext=function(n){function t(n){var i,e;return this.getChildContext||(i=new Set,(e={})[t.__c]=this,this.getChildContext=function(){return e},this.componentWillUnmount=function(){i=d},this.shouldComponentUpdate=function(n){this.props.value!=n.value&&i.forEach(function(n){n.__e=!0,P(n)})},this.sub=function(n){i.add(n);var t=n.componentWillUnmount;n.componentWillUnmount=function(){i&&i.delete(n),t&&t.call(n)}}),n.children}return t.__c="__cC"+l++,t.__=n,t.Provider=t.__l=(t.Consumer=function(n,t){return n.children(t)}).contextType=t,t},n.createElement=S,n.createRef=function(){return{current:d}},n.h=S,n.hydrate=function n(t,i){K(t,i,n)},n.isValidElement=r,n.options=i,n.render=K,n.toChildArray=function n(t,i){return i=i||[],t==d||"boolean"==typeof t||(m(t)?t.some(function(t){n(t,i)}):i.push(t)),i}});
//# sourceMappingURL=preact.umd.js.map
!function(n,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports,require("preact")):"function"==typeof define&&define.amd?define(["exports","preact"],t):t((n||self).preactHooks={},n.preact)}(this,function(n,t){var u,i,r,o,f=0,c=[],e=t.options,a=e.__b,v=e.__r,l=e.diffed,d=e.__c,s=e.unmount,p=e.__;function y(n,t){e.__h&&e.__h(i,n,f||t),f=0;var u=i.__H||(i.__H={__:[],__h:[]});return n>=u.__.length&&u.__.push({}),u.__[n]}function h(n){return f=1,m(j,n)}function m(n,t,r){var o=y(u++,2);if(o.t=n,!o.__c&&(o.__=[r?r(t):j(void 0,t),function(n){var t=o.__N?o.__N[0]:o.__[0],u=o.t(t,n);t!==u&&(o.__N=[u,o.__[1]],o.__c.setState({}))}],o.__c=i,!i.__f)){var f=function(n,t,u){if(!o.__c.__H)return!0;var i=o.__c.__H.__.filter(function(n){return!!n.__c});if(i.every(function(n){return!n.__N}))return!c||c.call(this,n,t,u);var r=o.__c.props!==n;return i.forEach(function(n){if(n.__N){var t=n.__[0];n.__=n.__N,n.__N=void 0,t!==n.__[0]&&(r=!0)}}),c&&c.call(this,n,t,u)||r};i.__f=!0;var c=i.shouldComponentUpdate,e=i.componentWillUpdate;i.componentWillUpdate=function(n,t,u){if(this.__e){var i=c;c=void 0,f(n,t,u),c=i}e&&e.call(this,n,t,u)},i.shouldComponentUpdate=f}return o.__N||o.__}function T(n,t){var r=y(u++,4);!e.__s&&g(r.__H,t)&&(r.__=n,r.u=t,i.__h.push(r))}function _(n,t){var i=y(u++,7);return g(i.__H,t)&&(i.__=n(),i.__H=t,i.__h=n),i.__}function b(){for(var n;n=c.shift();)if(n.__P&&n.__H)try{n.__H.__h.forEach(A),n.__H.__h.forEach(F),n.__H.__h=[]}catch(t){n.__H.__h=[],e.__e(t,n.__v)}}e.__b=function(n){i=null,a&&a(n)},e.__=function(n,t){n&&t.__k&&t.__k.__m&&(n.__m=t.__k.__m),p&&p(n,t)},e.__r=function(n){v&&v(n),u=0;var t=(i=n.__c).__H;t&&(r===i?(t.__h=[],i.__h=[],t.__.forEach(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(t.__h.forEach(A),t.__h.forEach(F),t.__h=[],u=0)),r=i},e.diffed=function(n){l&&l(n);var t=n.__c;t&&t.__H&&(t.__H.__h.length&&(1!==c.push(t)&&o===e.requestAnimationFrame||((o=e.requestAnimationFrame)||x)(b)),t.__H.__.forEach(function(n){n.u&&(n.__H=n.u),n.u=void 0})),r=i=null},e.__c=function(n,t){t.some(function(n){try{n.__h.forEach(A),n.__h=n.__h.filter(function(n){return!n.__||F(n)})}catch(u){t.some(function(n){n.__h&&(n.__h=[])}),t=[],e.__e(u,n.__v)}}),d&&d(n,t)},e.unmount=function(n){s&&s(n);var t,u=n.__c;u&&u.__H&&(u.__H.__.forEach(function(n){try{A(n)}catch(n){t=n}}),u.__H=void 0,t&&e.__e(t,u.__v))};var q="function"==typeof requestAnimationFrame;function x(n){var t,u=function(){clearTimeout(i),q&&cancelAnimationFrame(t),setTimeout(n)},i=setTimeout(u,100);q&&(t=requestAnimationFrame(u))}function A(n){var t=i,u=n.__c;"function"==typeof u&&(n.__c=void 0,u()),i=t}function F(n){var t=i;n.__c=n.__(),i=t}function g(n,t){return!n||n.length!==t.length||t.some(function(t,u){return t!==n[u]})}function j(n,t){return"function"==typeof t?t(n):t}n.useCallback=function(n,t){return f=8,_(function(){return n},t)},n.useContext=function(n){var t=i.context[n.__c],r=y(u++,9);return r.c=n,t?(null==r.__&&(r.__=!0,t.sub(i)),t.props.value):n.__},n.useDebugValue=function(n,t){e.useDebugValue&&e.useDebugValue(t?t(n):n)},n.useEffect=function(n,t){var r=y(u++,3);!e.__s&&g(r.__H,t)&&(r.__=n,r.u=t,i.__H.__h.push(r))},n.useErrorBoundary=function(n){var t=y(u++,10),r=h();return t.__=n,i.componentDidCatch||(i.componentDidCatch=function(n,u){t.__&&t.__(n,u),r[1](n)}),[r[0],function(){r[1](void 0)}]},n.useId=function(){var n=y(u++,11);if(!n.__){for(var t=i.__v;null!==t&&!t.__m&&null!==t.__;)t=t.__;var r=t.__m||(t.__m=[0,0]);n.__="P"+r[0]+"-"+r[1]++}return n.__},n.useImperativeHandle=function(n,t,u){f=6,T(function(){if("function"==typeof n){var u=n(t());return function(){n(null),u&&"function"==typeof u&&u()}}if(n)return n.current=t(),function(){return n.current=null}},null==u?u:u.concat(n))},n.useLayoutEffect=T,n.useMemo=_,n.useReducer=m,n.useRef=function(n){return f=5,_(function(){return{current:n}},[])},n.useState=h});
//# sourceMappingURL=hooks.umd.js.map
!function(n,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):n.htm=e()}(this,function(){var n=function(e,t,u,s){var r;t[0]=0;for(var p=1;p<t.length;p++){var h=t[p++],o=t[p]?(t[0]|=h?1:2,u[t[p++]]):t[++p];3===h?s[0]=o:4===h?s[1]=Object.assign(s[1]||{},o):5===h?(s[1]=s[1]||{})[t[++p]]=o:6===h?s[1][t[++p]]+=o+"":h?(r=e.apply(o,n(e,o,u,["",null])),s.push(r),o[0]?t[0]|=2:(t[p-2]=0,t[p]=r)):s.push(o)}return s},e=new Map;return function(t){var u=e.get(this);return u||(u=new Map,e.set(this,u)),(u=n(this,u.get(t)||(u.set(t,u=function(n){for(var e,t,u=1,s="",r="",p=[0],h=function(n){1===u&&(n||(s=s.replace(/^\s*\n\s*|\s*\n\s*$/g,"")))?p.push(0,n,s):3===u&&(n||s)?(p.push(3,n,s),u=2):2===u&&"..."===s&&n?p.push(4,n,0):2===u&&s&&!n?p.push(5,0,!0,s):u>=5&&((s||!n&&5===u)&&(p.push(u,0,s,t),u=6),n&&(p.push(u,n,0,t),u=6)),s=""},o=0;o<n.length;o++){o&&(1===u&&h(),h(o));for(var f=0;f<n[o].length;f++)e=n[o][f],1===u?"<"===e?(h(),p=[p],u=3):s+=e:4===u?"--"===s&&">"===e?(u=1,s=""):s=e+s[0]:r?e===r?r="":s+=e:'"'===e||"'"===e?r=e:">"===e?(h(),u=1):u&&("="===e?(u=5,t=s,s=""):"/"===e&&(u<5||">"===n[o][f+1])?(h(),3===u&&(p=p[0]),u=p,(p=p[0]).push(2,0,u),u=0):" "===e||"\t"===e||"\n"===e||"\r"===e?(h(),u=2):s+=e),3===u&&"!--"===s&&(u=4,p=p[0])}return h(),p}(t)),u),arguments,[])).length>1?u:u[0]}});
  </script>
  <script type="module">
    // Copyright 2025 mjbots Robotic Systems, LLC.  info@mjbots.com
    //
    // Licensed under the Apache License, Version 2.0 (the "License");
    // you may not use this file except in compliance with the License.
    // You may obtain a copy of the License at
    //
    //     http://www.apache.org/licenses/LICENSE-2.0
    //
    // Unless required by applicable law or agreed to in writing, software
    // distributed under the License is distributed on an "AS IS" BASIS,
    // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    // See the License for the specific language governing permissions and
    // limitations under the License.

    ////////////////////////////////////////////////////////////////////////////
    // # Conventions #
    //
    // This application uses moteus conventions for parameters:
    //
    // Phase current: The Q (or D) axis current vector.  If a wye motor is used,
    //   and the phase terminal voltages are 1, -0.5, -0.5, and each phase
    //   resistance is 1 Ohm, then the D axis phase current is 1A.
    //
    // Motor R: The resistance between any phase terminal and the virtual
    //   center, or 1/2 the line to line resistance as measured with an
    //   ohmmeter.
    //
    // Motor Kv: The observed peak to peak voltage as measured on an
    //   oscilloscope between any two phase terminals per RPM of rotor
    //   rotational speed.
    //
    // Motor L: moteus only works with non-salient motors currently, so it is
    //   assumed Ld == Lq.  The inductance, as the resistance, is measured
    //   between a phase terminal and the virtual center, or 1/2 the line to
    //   line inductance.

    const { h, render } = preact;
    const { useState, useEffect } = preactHooks;

    const html = htm.bind(h);

    // Mostly from 4o:
    function parseDotNotationQuery(queryString) {
      const params = new URLSearchParams(queryString);
      const result = {};

      for (const [key, value] of params.entries()) {
        const keys = key.split('.');
        let current = result;

        const parsedValue = (() => {
          if (value == "true") { return true; }
          if (value == "false") { return false; }
          if (!isNaN(value) && value.trim() != "") { return Number(value); }
          return value;
        })();

        keys.forEach((k, idx) => {
          if (idx === keys.length - 1) {
            current[k] = parsedValue;
          } else {
            if (!current[k]) current[k] = {};
            current = current[k];
          }
        });
      }

      return result;
    }

    // Also from 4o
    function objectToDotNotationParams(obj, prefix = '') {
      const pairs = [];

      if (obj == null) { return pairs; }

      for (const [key, value] of Object.entries(obj)) {
        const fullKey = prefix ? `${prefix}.${key}` : key;

        if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
          pairs.push(...objectToDotNotationParams(value, fullKey));
        } else {
          pairs.push(`${encodeURIComponent(fullKey)}=${encodeURIComponent(value)}`);
        }
      }

      return pairs;
    }

    // Yet more 4o constructs

    // Returns the largest x in [0, M] such that metric(x) === true
    // Assumes:
    // - metric(x) is monotonic: once true, remains so
    // If metric(M) === true, returns M.
    // If metric(0) === false, returns null.
    function bisectMaxTrue(M, metric, threshold) {
      let lo = 0;
      let hi = M;

      if (!metric(0)) {
        return null;
      }

      if (metric(M)) {
        return M;
      }

      // Invariant: metric(lo) === true, metric(hi) === false, lo < hi
      while (lo < (hi - threshold)) {
        const mid = 0.5 * (lo + hi);
        if (metric(mid)) {
          lo = mid;
        } else {
          hi = mid;
        }
      }

      return lo;
    }

    // more from 4o
    function shallowEqual(objA, objB) {
      if (objA === objB) return true;

      if (typeof objA !== 'object' || objA === null ||
          typeof objB !== 'object' || objB === null) {
        return false;
      }

      const keysA = Object.keys(objA);
      const keysB = Object.keys(objB);

      if (keysA.length !== keysB.length) return false;

      for (const key of keysA) {
        if (!Object.prototype.hasOwnProperty.call(objB, key) || objA[key] !== objB[key]) {
          return false;
        }
      }

      return true;
    }


    // A regular 2D array of data with x and y coordinates for each row and
    // column.  The coordinates must be sorted.
    class Interpolator2D {
      constructor(xlabels, ylabels, data) {
        this.xlabels = xlabels;
        this.ylabels = ylabels;
        this.data = data;

        // Validate that our data has the right number of rows and columns.
        if (data.length != this.ylabels.length) {
          throw new Error("Data matrix does not match label row count");
        }
        data.map((row) => {
          if (row.length != this.xlabels.length) {
            throw new Error("Data matrix does not match label column count");
          }
        });
      }

      // Also from 4o:
      //
      // Return a linear interpolation between nearest points, or if the
      // requested point is outside the allowed bounds, just extend the
      // nearest edge or corner indefinitely.
      interpolate(x, y) {
        // Helper function: Given a sorted label array and a value, returns
        // the lower and upper indices and the interpolation factor.
        function getInterval(labels, value) {
          const n = labels.length;
          if (value <= labels[0]) {
            return { indexLow: 0, indexHigh: 0, t: 0 };
          }
          if (value >= labels[n - 1]) {
            return { indexLow: n - 1, indexHigh: n - 1, t: 0 };
          }
          // Find the interval where labels[i] <= value <= labels[i+1]
          for (let i = 0; i < n - 1; i++) {
            if (value >= labels[i] && value <= labels[i + 1]) {
              let t = (value - labels[i]) / (labels[i + 1] - labels[i]);
              return { indexLow: i, indexHigh: i + 1, t: t };
            }
          }
          // Fallback (should never be reached)
          return { indexLow: n - 1, indexHigh: n - 1, t: 0 };
        }

        // Get the intervals and interpolation factors for x and y.
        const xInterval = getInterval(this.xlabels, x);
        const yInterval = getInterval(this.ylabels, y);

        const i0 = xInterval.indexLow;
        const i1 = xInterval.indexHigh;
        const t = xInterval.t;

        const j0 = yInterval.indexLow;
        const j1 = yInterval.indexHigh;
        const u = yInterval.t;

        // Retrieve the four surrounding grid values.
        const v00 = this.data[j0][i0];
        const v01 = this.data[j0][i1];
        const v10 = this.data[j1][i0];
        const v11 = this.data[j1][i1];

        // Interpolate along x for the two rows.
        const interpX0 = (1 - t) * v00 + t * v01;
        const interpX1 = (1 - t) * v10 + t * v11;

        // Interpolate the two results along y.
        return (1 - u) * interpX0 + u * interpX1;
      }
    }

    ///////////////////////////////////////////////
    // Now done with the 4o generated helpers.

    class RenderedValue {
      constructor(value, {footnotes, numberRender, operatingPoint}){
        this.value = value;
        this.footnotes = footnotes ?? [];
        this.numberRender = numberRender ?? ((value) => value.toFixed(2));
        this.operatingPoint = operatingPoint;
      }

      render() {
        return html`${this.numberRender(this.value)}`;
      }
    }

    class OperatingPoint {
      constructor({controllerTempC=null,
                   motorTempC=null,
                   supplyPowerW=null,
                   supplyCurrentA=null,
                   phaseCurrentA=null,
                   motorTorqueNm=null,
                   copperLossW=null,
                   ironLossW=null,
                   controllerLossW=null,
                   mechanicalPowerW=null,
                   efficiencyPercent=null}) {
        this.controllerTempC = controllerTempC;
        this.motorTempC = motorTempC;
        this.supplyPowerW = supplyPowerW;
        this.supplyCurrentA = supplyCurrentA;
        this.phaseCurrentA = phaseCurrentA;
        this.motorTorqueNm = motorTorqueNm;
        this.copperLossW = copperLossW;
        this.ironLossW = ironLossW;
        this.controllerLossW = controllerLossW;
        this.mechanicalPowerW = mechanicalPowerW;
        this.efficiencyPercent = efficiencyPercent;
      }

      render() {
        return html`
        <h3>Operating Point</h3>
        <table>
          ${[['controllerTempC', 'Controller temperature (C)'],
             ['motorTempC', 'Motor temperature (C)'],
             ['supplyPowerW', 'Supply power (W)'],
             ['supplyCurrentA', 'Supply current (A)'],
             ['phaseCurrentA', 'Phase current (A)'],
             ['motorTorqueNm', 'Motor torque (Nm)'],
             ['copperLossW', 'Copper loss (W)'],
             ['ironLossW', 'Iron loss (W)'],
             ['controllerLossW', 'Controller loss (W)'],
             ['mechanicalPowerW', 'Mechanical power (W)'],
             ['efficiencyPercent', 'Efficiency (%)'],
           ].map(([attrName, displayName]) =>
           html`${this[attrName] ? html`<tr><td>${displayName}</td><td>${this[attrName].toFixed(3)}</td></tr>` : ''}`)
           }
        </table>
        `
      }
    }

    class SelectionBase {
      name() {
        return this.displayName();
      }

      getState() {
        return {};
      }

      render({}, state) {
        return html`${this.displayName(state)}`;
      }

      productInfo() {
        return null;
      }
    }

    class VoltageSelection extends SelectionBase {
      constructor(voltage) {
        super();
        this.voltage = voltage;
      }

      getVoltage() {
        return this.voltage;
      }

      displayName() {
        return `${this.voltage}V`;
      }
    }

    class PwmSelection extends SelectionBase {
      constructor(pwm) {
        super();
        this.pwm = pwm;
      }

      getPwm(state, controller, controllerState) {
        if (this.pwm == 'default') {
          return controller.getDefaultPwm(controllerState);
        }
        return this.pwm;
      }

      displayName() {
        return this.pwm == 'default' ? 'default' : `${this.pwm}Hz`;
      }
    }

    class CoolingSelection extends SelectionBase {
      constructor(cooling, userDisplayName) {
        super();
        this.cooling = cooling;
        this.userDisplayName = userDisplayName;
      }

      getCooling() {
        return this.cooling;
      }

      name() {
        return this.cooling;
      }

      displayName() {
        return `${this.userDisplayName}`;
      }

      productInfo() {
        return this?.productInfo;
      }
    }

    class Controller extends SelectionBase {
      constructor(props, power, thermal) {
        super();
        this.props = props;
        this.power = power;
        this.thermal = thermal;
        this.k0 = new Interpolator2D(this.power.voltages, this.power.pwms, this.power.k0);
        this.k1 = new Interpolator2D(this.power.voltages, this.power.pwms, this.power.k1);
        this.k2 = new Interpolator2D(this.power.voltages, this.power.pwms, this.power.k2);
      }

      getState() {
        return {};
      }

      getThermalProperties(state, coolingName) {
        if (this.thermal?.[coolingName] == null) {
          return [null, null];
        }
        return [this.thermal[coolingName]['R'], this.thermal[coolingName]['C']];
      }

      getMaxTemp() {
        return this.props['maxTemp'];
      }

      getMaxVoltage() {
        return this.props['maxVoltage'];
      }

      getMaxSpeed(state, voltage, voltageState, motor, motorState) {
        return voltage.getVoltage(voltageState) * motor.getKv(motorState) * this.getSpeedFactor() / 60.0;
      }

      getCurrentFromThermalPower(state, thermalW, {voltage, pwm}) {
        const [k0,k1,k2] = [
          this.k0.interpolate(voltage, pwm),
          this.k1.interpolate(voltage, pwm),
          this.k2.interpolate(voltage, pwm),
        ];
        const c = k0;
        const b = k1;
        const a = k2;

        return Math.min(this.getMaxCurrent(state, voltage),
          (-b + Math.sqrt(Math.max(0, b * b - 4 * a * (c - thermalW)))) / (2 * a));
      }

      getThermalPower(state, {voltage, pwm, current}) {
        const [k0, k1, k2] = [
          this.k0.interpolate(voltage, pwm),
          this.k1.interpolate(voltage, pwm),
          this.k2.interpolate(voltage, pwm),
        ];

        const thermalPower = k0 + k1 * current + k2 * current * current;
        return thermalPower;
      }

      getTemperature(state, {thermalPower, coolingName, timePeriodS, ambientTemp}) {
        const [R, C] = this.getThermalProperties(state, coolingName);
        return ambientTemp + thermalPower * R * (1.0 - Math.exp(-timePeriodS / (R * C)));
      }

      name() {
        return this.props['name'];
      }

      displayName() {
        return this.props['name'];
      }

      productInfo() {
        return this.props?.productInfo;
      }

      render() {
        return html`${this.displayName()}`;
      }
    }

    class MoteusController extends Controller {
      constructor(props, power, thermal) {
        super(props, power, thermal);
      }

      getDefaultPwm() {
        return this.props['defaultPwm'];
      }

      getMaxPower(state, voltage, pwm) {
        const [P_l_W, V_l, P_h_W, V_h] = [
          this.props.P_l_W, this.props.V_l,
          this.props.P_h_W, this.props.V_h,
        ];
        const basePower = (() => {
          if (voltage <= V_l) return P_l_W;
          if (voltage >= V_h) return P_h_W;
          return (voltage - V_l) / (V_h - V_l) * (P_h_W - P_l_W) + P_l_W;
        })();
        const pwmDerate = pwm / 30000;
        return basePower * pwmDerate;
      }

      getSpeedFactor() {
        // Measured with moteus-c1, moteus-n1 and moteus-x1 on a MAD 8318 at 12V
        // and 18V on 2025-04-17.
        return 0.75;
      }

      getMaxCurrent() {
        return this.props['maxCurrent'];
      }
    }

    class OdriveController extends Controller {
      constructor(props, power, thermal) {
        super(props, power, thermal);
      }

      getDefaultPwm() {
        return 24000.0;
      }

      getMaxPower(state) {
        return this.props['P_W'];
      }

      getMaxCurrent(state, voltage) {
        if (voltage < this.props['maxCurrentDerateVoltage']) {
          return this.props['maxCurrent'];
        } else {
          return this.props['maxCurrent'] + (this.props['derateCurrent'] - this.props['maxCurrent']) *
              (voltage - this.props['maxCurrentDerateVoltage']) /
              (this.props['maxVoltage'] - this.props['maxCurrentDerateVoltage']);
        }
      }
    }

    class MoteusC1 extends MoteusController {
      constructor() {
        super({
          name: 'moteus-c1',
          maxTemp: 60.0,
          maxCurrent: 20.0,
          maxVoltage: 51.0,
          P_l_W: 250.0,
          V_l: 28.0,
          P_h_W: 150.0,
          V_h: 41.0,
          defaultPwm: 30000,
          productInfo: html`<a href="https://mjbots.com/products/moteus-c1" target="_blank">[product page]</a>`,
        },
        {
          'voltages' : [12, 24, 36, 48],
          'pwms' : [15000, 30000, 40000, 60000],
          'k0' : [[.612, .906, 1.161, 1.378],
                  [.727, .986, 1.161, 1.515],
                  [.677, .988, 1.256, 1.576],
                  [.718, 1.034, 1.367, 1.710]],
          'k1' : [[0.000, 0.017, 0.043, 0.112],
                  [0.009, 0.107, 0.224, 0.248],
                  [0.073, 0.176, 0.281, 0.360],
                  [0.155, 0.330, 0.468, 0.544]],
          'k2' : [[0.046, 0.049, 0.051, 0.046],
                  [0.051, 0.051, 0.048, 0.050],
                  [0.051, 0.053, 0.055, 0.049],
                  [0.052, 0.054, 0.053, 0.056]],
        },
        {
          'none' :         {R: 11.3, C:10.7},
          'heatspreader' : {R: 11.2, C:10.7},
          '5vfan' :        {R: 2.55, C:10.7},
          'max' :          {R: 2.55, C:10.7},
        });
      }
    }

    class MoteusR4 extends MoteusController {
      constructor() {
        super({
          name: 'moteus-r4',
          maxTemp: 60.0,
          maxCurrent: 100.0,
          maxVoltage: 42.0,
          P_l_W: 900.0,
          V_l: 30.0,
          P_h_W: 400.0,
          V_h: 38.0,
          defaultPwm: 30000,
          productInfo: html`<a href="https://mjbots.com/products/moteus-r4-11" target="_blank">[product page]</a>`,
        },
        {
          'voltages' : [12, 24, 36, 42],
          'pwms' : [15000, 30000, 40000, 60000],
          'k0' : [[0.514, 0.938, 1.266, 1.427],
                  [0.792, 1.167, 1.593, 1.834],
                  [0.851, 1.392, 1.765, 2.064],
                  [0.974, 1.504, 2.098, 2.524]],
          'k1' : [[0.022, 0.007, 0.015, 0.018],
                  [0.021, 0.035, 0.045, 0.050],
                  [0.030, 0.020, 0.092, 0.091],
                  [0.046, 0.110, 0.139, 0.142]],
          'k2' : [[0.010, 0.011, 0.011, 0.011],
                  [0.011, 0.012, 0.013, 0.013],
                  [0.011, 0.014, 0.012, 0.013],
                  [0.013, 0.013, 0.015, 0.018]],
        },
        {
          'none' :         {R: 10.0, C:12.7},
          '5vfan' :        {R: 3.7,  C:12.7},
          'heatspreader':  {R: 8.6,  C:13.9},
          'heatspreader_4030sink': {R:6.2, C:19.4},
          'max':           {R: 2.4,  C:12.7},
        });
      }
    }

    class MoteusN1 extends MoteusController {
      constructor() {
        super({
          name: 'moteus-n1',
          maxTemp: 60.0,
          maxCurrent: 100.0,
          maxVoltage: 54.0,
          P_l_W: 2000.0,
          V_l: 36.0,
          P_h_W: 1000.0,
          V_h: 44.0,
          defaultPwm: 30000,
          productInfo: html`<a href="https://mjbots.com/products/moteus-n1" target="_blank">[product page]</a>`,
        },
        {
          'voltages' : [12, 24, 36, 48, 54],
          'pwms' : [15000, 30000, 40000, 60000],
          'k0' : [[0.681, 1.102, 1.467, 1.799, 2.063],
                  [0.884, 1.470, 1.983, 2.612, 2.862],
                  [1.025, 1.671, 2.302, 3.068, 3.559],
                  [1.203, 2.146, 3.036, 3.400, 4.000]],
          'k1' : [[0.016, 0.013, 0.020, 0.032, 0.000],
                  [0.033, 0.027, 0.049, 0.073, 0.079],
                  [0.023, 0.055, 0.093, 0.112, 0.120],
                  [0.055, 0.076, 0.131, 0.140, 0.150]],
          'k2' : [[0.011, 0.012, 0.012, 0.013, 0.016],
                  [0.012, 0.014, 0.015, 0.015, 0.018],
                  [0.013, 0.014, 0.015, 0.014, 0.030],
                  [0.014, 0.018, 0.019, 0.022, 0.035]],
        },
        {
          'none' :         {R: 11.7, C:10.3},
          '5vfan' :        {R: 4.1,  C:10.3},
          'heatspreader':  {R: 9.8,  C:12.2},
          'heatspreader_4030sink': {R:7.2, C:16.8},
          'max':           {R: 3.0,  C:10.3},
        });
      }
    }

    class MoteusX1 extends MoteusController {
      constructor() {
        super({
          name: 'moteus-x1',
          maxTemp: 100.0,
          maxCurrent: 120.0,
          maxVoltage: 54.0,
          P_l_W: 2000.0,
          V_l: 36.0,
          P_h_W: 1000.0,
          V_h: 44.0,
          defaultPwm: 20000,
          productInfo: html`<a href="https://mjbots.com/products/moteus-x1" target="_blank">[product page]</a>`,
        },
        {
          'voltages' : [12, 24, 36, 48, 54],
          'pwms' : [15000, 20000, 24000, 30000, 40000, 60000],
          'k0' : [[0.615, 0.983, 1.266, 1.501, 1.652],
                  [0.744, 1.025, 1.468, 1.825, 2.051],
                  [0.822, 1.325, 1.618, 2.025, 2.321],
                  [1.119, 1.403, 1.824, 2.326, 2.622],
                  [1.135, 1.662, 2.251, 2.811, 3.253],
                  [1.465, 2.222, 3.094, 4.130, 4.501]],
          'k1' : [[0.075, 0.090, 0.105, 0.133, 0.133],
                  [0.094, 0.128, 0.136, 0.137, 0.137],
                  [0.094, 0.124, 0.160, 0.180, 0.172],
                  [0.090, 0.163, 0.208, 0.213, 0.223],
                  [0.148, 0.227, 0.253, 0.278, 0.247],
                  [0.213, 0.317, 0.354, 0.192, 0.286]],
          'k2' : [[0.007, 0.008, 0.010, 0.010, 0.011],
                  [0.007, 0.008, 0.011, 0.014, 0.015],
                  [0.009, 0.010, 0.013, 0.015, 0.017],
                  [0.010, 0.011, 0.014, 0.019, 0.021],
                  [0.010, 0.012, 0.021, 0.026, 0.037],
                  [0.014, 0.023, 0.043, 0.090, 0.083]],
        },
        {
          'none' :         {R: 8.1, C:14.8},
          '5vfan' :        {R: 2.8, C:14.8},
          '12vfan' :       {R: 2.1, C:14.8},
          'heatspreader_4010sink': { R:7.2, C:16.8},
          'heatspreader_4030sink': { R:6.0, C:20.0},
          'heatspreader':  {R: 8.6, C:14.8},
          'max' :          {R: 1.9, C:14.8},
        });
      }
    }

    class OdrivePro extends OdriveController {
      constructor() {
        super({
          name: 'odrive-pro',
          // The default thermal limiting ranges from 84C - 104C, so will
          // typically saturate around 95.
          maxTemp: 95,
          maxCurrent: 120.0,
          maxCurrentDerateVoltage: 100.0,
          derateCurrent: 120.0,
          maxVoltage: 58.0,
          P_W: 5000.0,
          productInfo: html`<a href="https://shop.odriverobotics.com/products/odrive-pro" target="_blank">[product page]</a>`,

        },
        {
          voltages: [12, 24, 36, 48, 54],
          pwms: [24000],
          'k0': [[1.410, 1.996, 2.362, 2.823, 3.030]],
          'k1': [[0.091, 0.115, 0.145, 0.161, 0.156]],
          'k2': [[0.008, 0.009, 0.010, 0.010, 0.012]],
        },
        {
          'none':         {R: 6.7, C: 30.4},
          'heatspreader': {R: 5.4, C: 71.7},
          // The fan options were taken with the heatspreader attached, since
          // the FETs are otherwise on the wrong side of the board when used
          // with the onboard encoder.
          '5vfan':        {R: 1.5, C: 71.7 },
          '12vfan':       {R: 1.0, C: 71.7 },
          'max':          {R: 1.0, C: 71.7},
        }
        );
      }

      getSpeedFactor() {
        // Measured with a MAD 8318 at 12V and 18V and harmonic compensation.
        // In velocity ramp control mode, this was the highest stable velocity,
        // if a velocity even slightly higher than this was commanded, the
        // control seemed to have some problems and slowed down to a steady
        // state which was more like 0.8 or 0.85, but clearly had some wind up
        // issues of some sort.
        //
        // Measured with fw 0.6.11 on 2025-04-17.
        return 0.93;
      }
    }

    class OdriveS1 extends OdriveController {
      constructor() {
        super({
          name: 'odrive-s1',
          // The default thermal limiting ranges from 84C - 104C, so will
          // typically saturate around 95.
          maxTemp: 95.0,
          maxCurrent: 80.0,
          maxCurrentDerateVoltage: 36.0,
          derateCurrent: 40.0,
          maxVoltage: 50.5,
          P_W: 2000.0,
          productInfo: html`<a href="https://shop.odriverobotics.com/products/odrive-s1" target="_blank">[product page]</a>`,
        },
        {
          voltages: [12, 24, 36, 48],
          pwms: [24000],
          'k0': [[0.839, 1.037, 1.213, 1.328 ]],
          'k1': [[0.066, 0.117, 0.153, 0.179 ]],
          'k2': [[0.008, 0.008, 0.008, 0.008 ]],
        },
        {
          'none':  {R: 6.6, C: 35.8},
          'heatspreader': {R: 4.7, C: 85.8},
          // The fan options were taken with the heatspreader attached, since
          // the FETs are otherwise on the wrong side of the board when used
          // with the onboard encoder.
          '5vfan': {R: 1.2, C: 85.8},
          'max':   {R: 1.2, C: 85.8},
        }
        );
      }

      getSpeedFactor() {
        // Measured empirically on a MAD 8318 using fw 0.6.11 w/ harmonic
        // compensation on 2025-04-17.
        //  12V - 17.7Hz
        //  18V - 25.7Hz
        return 0.75;
      }
    }

    class OdriveMicro extends OdriveController {
      constructor() {
        super({
          name: 'odrive-micro',
          // The default thermal limiting on the board I have is from 135C -
          // 145C.  However, that limit is applied to the Tj model of
          // the driver IC, not the board temperature, like the limits are for
          // the pro and S1.  At maximum power, the board temperature itself
          // stabilizes at roughly 80C.
          maxTemp: 80.0,
          maxCurrent: 7.0,
          maxCurrentDerateVoltage: 100.0,
          derateCurrent: 40.0,
          maxVoltage: 31.0,
          P_W: 100.0,
          productInfo: html`<a href="https://shop.odriverobotics.com/products/odrive-micro" target="_blank">[product page]</a>`,
        },
        {
          voltages: [12, 24],
          pwms: [24000],
          'k0': [[0.607, 0.820]],
          'k1': [[0.082, 0.301]],
          'k2': [[0.148, 0.149]],
        },
        {
          'none':  {R: 16.0, C: 7.9},
          'max':   {R: 6.2, C: 10.0},
        }
        );
      }

      getSpeedFactor() {
        // Measured empirically on a MAD 8318 using fw 0.6.11 on 2025-04-17 w/
        // harmonic compensation.
        // 12V - 17.27Hz
        // 18V - 25.75Hz
        return 0.75;
      }
    }

    class Motor extends SelectionBase {
      constructor({name, displayName, maxTemp, Kv, R, L, thermal,
                   rotationCurrentCutoff=1000,
                   rotationCurrentScale=1,
                   rotationTorqueScale=1,
                   d0, d1,
                   productInfo=null}) {
        super();
        this.nameValue = name;
        this.displayNameValue = displayName;
        this.productInfoValue = productInfo;
        this.maxTemp = maxTemp;
        this.Kv = Kv;
        this.R = R;
        this.L = L;
        this.thermal = thermal;
        this.rotationCurrentScale = rotationCurrentScale;
        this.rotationCurrentCutoff = rotationCurrentCutoff;
        this.rotationTorqueScale = rotationTorqueScale;
        this.d0 = d0;
        this.d1 = d1;

        this.kt = 8.3 / this.Kv;
      }

      getThermalProperties(state, coolingName) {
        if (this.thermal?.[coolingName] == null) {
          return [null, null];
        }
        return [this.thermal[coolingName]['R'], this.thermal[coolingName]['C']];
      }

      getMaxTemp() {
        return this.maxTemp;
      }

      getKv() {
        return this.Kv;
      }

      name() {
        return this.nameValue;
      }

      displayName() {
        return this.displayNameValue;
      }

      productInfo() {
        return this.productInfoValue;
      }

      render() {
        return html`${this.displayName()}`;
      }

      getBackEmf(velocityHz) {
        return velocityHz * 60 / this.getKv();
      }

      getPower(current, velocityHz) {
        const torque = current < this.rotationCurrentCutoff ?
          (this.kt * current) :
          (this.kt * this.rotationCurrentCutoff +
           this.rotationTorqueScale * Math.log2(
              1 + (current - this.rotationCurrentCutoff) * this.rotationCurrentScale));
        const Pmech = 2 * Math.PI * torque * velocityHz;
        const Pcopper = 1.5 * current * current * this.R;
        const ironDrag = this.d0 + this.d1 * velocityHz;
        const Piron = velocityHz == 0 ? 0 : (2 * Math.PI * velocityHz * ironDrag);
        return {
          torque: torque,
          Pmech: Pmech,
          Pcopper: Pcopper,
          Piron: Piron,
          Ptotal: Pmech + Pcopper + Piron,
        };
      }

      getCurrentFromThermalPowerAndVelocity(state, thermalW, velocityHz, maxControllerPower, supplyVoltageMargin) {
        // TODO: Should we model the fact that resistance tends to go up with
        // temperature?

        // Our convention is that resistance is measured to the virtual center,
        // and that phase current is measured as per moteus convention.  Thus:
        //
        // torque = Kt * I + ts * log2(1 + (I - Ic)*is)
        // Pmech = 2 * pi * torque * velocityHz
        // Pcopper = 1.5 * I^2 * R
        // Ptotal = Pcopper + Piron + Pmech

        // and (Pcopper + Piron) < thermalW
        // and (Ptotal) < maxControllerPower

        const ImaxPossible = Math.min(supplyVoltageMargin / this.R,
          Math.sqrt(Math.min(thermalW, maxControllerPower) / 1.5 / this.R));
        // This equation is not really possible to solve directly, so lets just
        // find it numerically.  The answer is much easier if the velocity is 0.
        if (velocityHz == 0.0) {
          return ImaxPossible;
        }

        // Bisect between 0 and ImaxPossible to find a current which is as large
        // as possible while satisfying both of our inequalities.
        const valid = (I) => {
          const r = this.getPower(I, velocityHz);
          return (((r.Pcopper + r.Piron) < thermalW) &&
                  (r.Ptotal < maxControllerPower));
        };

        return bisectMaxTrue(ImaxPossible, valid, 0.01);
      }

      getTorqueFromCurrent(state, currentA) {
        if (currentA < this.rotationCurrentCutoff) {
          return currentA * this.kt;
        }
        return (this.rotationCurrentCutoff * this.kt +
                this.rotationTorqueScale *
                (Math.log2(Math.max(1.0 + (currentA - this.rotationCurrentCutoff) *
                this.rotationCurrentScale, 0.00001))));
      }

      getMaxVelocityHz(state, controllerInstance, controllerState, voltage, voltageState) {
        return controllerInstance.getSpeedFactor(controllerState) * voltage.getVoltage(voltageState) * this.Kv / 60.0;
      }

      getOperatingPoint(motorState, {current, velocityHz, timePeriodS=Infinity, ambientTemp, motorCoolingValue}) {
        const result = new OperatingPoint({});
        result.motorTorqueNm = this.getTorqueFromCurrent(motorState, current);
        result.phaseCurrentA = current;

        const r = this.getPower(current, velocityHz);
        result.copperLossW = r.Pcopper;
        result.ironLossW = r.Piron;
        result.mechanicalPowerW = r.Pmech;
        const motorLossPower = r.Pcopper + r.Piron;

        const [R, C] = this.getThermalProperties(motorState, motorCoolingValue);
        result.motorTempC = ambientTemp + motorLossPower * R * (1.0 - Math.exp(-timePeriodS / (R * C)));

        return result;
      }
    }

    class MotorCoolingSelection extends SelectionBase {
      constructor(motorCooling, userDisplayName) {
        super();
        this.motorCooling = motorCooling;
        this.userDisplayName = userDisplayName;
      }

      getMotorCooling() {
        return this.motorCooling;
      }

      name() {
        return this.motorCooling;
      }

      displayName() {
        return `${this.userDisplayName}`
      }

      productInfo() {
        return this?.productInfo;
      }
    }

    class OutputContinuousCurrentBase extends SelectionBase {
      constructor(name) {
        super();
        this.nameValue = name;
      }

      name() {
        return this.nameValue;
      }

      evaluateBase({ controller, controllerState, voltage, voltageState, pwm, pwmState, cooling, coolingState, general, generalState }, timePeriodS) {
        const coolingName = cooling.getCooling(coolingState);
        const [R, C] = controller.getThermalProperties(controllerState, coolingName);
        if (R == null || C == null) {
          return null;
        }
        const ambientTemp = general.ambient.getTemperature(generalState.ambient);
        const controllerMaxTemp = controller.getMaxTemp(controllerState)
        const deltaT = controllerMaxTemp - ambientTemp;

        const args = {
          voltage: voltage.getVoltage(voltageState),
          pwm: pwm.getPwm(pwmState, controller, controllerState),
        };

        if (args.voltage > controller.getMaxVoltage()) {
          return null;
        }

        const thermalW = (timePeriodS == Infinity) ? (deltaT / R) :
            (deltaT) / (R * (1.0 - Math.exp(-timePeriodS / (R * C))));

        const result = controller.getCurrentFromThermalPower(controllerState, thermalW, args);
        if (result < 0) {
          return null;
        }

        const op = new OperatingPoint({
          controllerTempC: controllerMaxTemp,
          controllerLossW: thermalW,

          // TODO: Fill in motor parameters if we have a motor.
        });
        return new RenderedValue(
          result, {numberRender:((value) => `${value.toFixed(2)} A`), operatingPoint: op});
      }
    }

    class OutputContinuousCurrent extends OutputContinuousCurrentBase {
      constructor(name, timePeriodS) {
        super(name);
        this.timePeriodS = timePeriodS;
      }

      displayName(state) {
        return `Phase current for ${this.timePeriodS}${Number.isFinite(this.timePeriodS) ? 's' : ''}`;
      }

      evaluate(args) {
        return this.evaluateBase(args, this.timePeriodS);
      }
    }

    class OutputContinuousCurrentConfigurable extends OutputContinuousCurrentBase {
      constructor(name) {
        super(name);
      }

      getState() {
        return {
          timePeriodS: 2,
        };
      }

      evaluate(args) {
        return this.evaluateBase(args, args.outputState.timePeriodS)
      }

      displayName(state) {
        return `Phase current for ${state.timePeriodS}${Number.isFinite(state.timePeriodS) ? 's' : ''}`;
      }
    }

    class OutputMotorTorqueBase extends SelectionBase {
      constructor(name, timePeriodS) {
        super();
        this.nameValue = name;
        this.controllerOutput = new OutputContinuousCurrentConfigurable(name);
      }

      name() {
        return this.nameValue;
      }

      evaluateBase(args, velocityHz, timePeriodS) {
        const { controller, controllerState,
                voltage, voltageState, pwm, pwmState, cooling, coolingState,
                motor, motorState, motorCooling, motorCoolingState,
                general, generalState} = args;

        if (motor == null) {
          return null;
        }

        if (velocityHz > motor.getMaxVelocityHz(motorState, controller, controllerState, voltage, voltageState)) {
          return null;
        }

        const ambientTemp = general.ambient.getTemperature(generalState.ambient);
        const motorCoolingValue = motorCooling.getMotorCooling(motorCoolingState);

        const [R, C] = motor.getThermalProperties(motorState, motorCoolingValue);
        if (R == null || C == null) {
          return null;
        }

        const deltaT = motor.getMaxTemp(motorState) - ambientTemp;

        const thermalW = (timePeriodS == Infinity) ?
          deltaT / R :
          deltaT / (R * (1.0 - Math.exp(-timePeriodS / (R * C))));

        const voltageValue = voltage.getVoltage(voltageState);
        const pwmValue = pwm.getPwm(pwmState, controller, controllerState);
        const supplyVoltageMargin = Math.max(0, 0.5 * (controller.getSpeedFactor() * voltageValue - motor.getBackEmf(velocityHz)));

        const motorCurrent =
            motor.getCurrentFromThermalPowerAndVelocity(
                    motorState, thermalW, velocityHz,
                    controller.getMaxPower(controllerState, voltageValue, pwmValue),
                    supplyVoltageMargin);

        const controllerCurrent = this.controllerOutput.evaluateBase(args, timePeriodS)?.value ?? null;
        if (controllerCurrent == null) {
          return null;
        }
        const finalCurrent = Math.min(motorCurrent, controllerCurrent);

        const motorTorque = motor.getTorqueFromCurrent(motorState, finalCurrent);

        const opargs = {
          voltage: voltageValue,
          pwm: pwmValue,
          current: finalCurrent,
          coolingName: cooling.getCooling(coolingState),
          timePeriodS: timePeriodS,
          ambientTemp: ambientTemp,
        };
        const op = motor.getOperatingPoint(motorState, {
          current: finalCurrent, velocityHz: velocityHz,
          timePeriodS: timePeriodS, ambientTemp: opargs.ambientTemp,
          motorCoolingValue: motorCoolingValue});
        const thermalPower = controller.getThermalPower(controllerState, opargs);
        op['controllerLossW'] = thermalPower;
        op['controllerTempC'] = controller.getTemperature(controllerState, {...opargs, thermalPower: thermalPower});
        const totalLossPowerW = op.controllerLossW + op.copperLossW + op.ironLossW;
        op['supplyCurrentA'] = (totalLossPowerW + op.mechanicalPowerW) / voltageValue;
        op['supplyPowerW'] = op.mechanicalPowerW + totalLossPowerW;
        op['efficiencyPercent'] = 100.0 * op.mechanicalPowerW / (op.mechanicalPowerW + totalLossPowerW);
        return new RenderedValue(motorTorque, {numberRender: ((value) => `${value.toFixed(3)} Nm`), operatingPoint: op});
      }
    }

    class OutputMotorTorque extends OutputMotorTorqueBase {
      constructor(name, timePeriodS, velocityHz) {
        super(name, timePeriodS);
        this.velocityHz = velocityHz;
        this.timePeriodS = timePeriodS;
      }

      displayName() {
        const t = this.timePeriodS;
        return `Torque at ${this.velocityHz}Hz for ${t}${Number.isFinite(t) ? 's' : ''}`;
      }

      evaluate(args) {
        return this.evaluateBase(args, this.velocityHz, this.timePeriodS);
      }
    }

    class OutputMotorTorqueConfigurable extends OutputMotorTorqueBase {
      constructor(name) {
        super(name);
      }

      getState() {
        return {
          velocity: 0,
          timePeriodS: Infinity,
        };
      }

      displayName(state) {
        const t = state.timePeriodS;
        const v = state.velocity;
        return `Torque at ${v}Hz for ${t}${Number.isFinite(t) ? 's' : ''}`;
      }

      render({}, state, onChange) {
        const t = state.timePeriodS;
        const v = state.velocity;

        return html`Torque at
        <input type="number" value="${v}" style="width: 6ch;"
               onchange=${(e) => onChange({velocity:Number(e.target.value), timePeriodS: t})}/>Hz
        for
        <input type="number" value="${t}" style="width: 6ch;"
               onchange=${(e) => onChange({velocity:v, timePeriodS:Number(e.target.value) == 0 ? Infinity : Number(e.target.value)})}/>s
        `;
      }

      evaluate(args) {
        return this.evaluateBase(args, args.outputState.velocity, args.outputState.timePeriodS);
      }
    }

    class OutputMaxPower extends SelectionBase {
      constructor() {
        super();
      }

      name() {
        return 'Pmax';
      }

      displayName() {
        return html`Max Power`;
      }

      evaluate({controller, controllerState, voltage, voltageState, pwm, pwmState}) {
        const voltageValue = voltage.getVoltage(voltageState);
        const pwmValue = pwm.getPwm(pwmState, controller, controllerState);

        return new RenderedValue(controller.getMaxPower(controllerState, voltageValue, pwmValue),
            {numberRender: ((value) => `${value.toFixed(1)} W`), operatingPoint: new OperatingPoint({})});
      }
    }

    class OutputMotorMaxSpeed extends SelectionBase {
      constructor() {
        super();
      }

      name() {
        return 'Vmax';
      }

      displayName() {
        return html`Max Speed`;
      }

      evaluate({controller, controllerState, voltage, voltageState, motor, motorState}) {
        return new RenderedValue(
          controller.getMaxSpeed(controllerState, voltage, voltageState, motor, motorState),
          {numberRender: ((value) => `${value.toFixed(1)} Hz`), operatingPoint: new OperatingPoint({})});
      }
    }

    class AmbientTemperature extends SelectionBase {
      constructor() {
        super();
      }

      getState() {
        return {
          temperature: 25.0,
        };
      }

      name() {
        return 'ambient';
      }

      displayName(state) {
        return `Ambient temperature ${state.temperature}`;
      }

      render({}, state, onChange) {
        const t = state.temperature;

        return html`Ambient temperature: <input type="number" value="${t}" style="width: 6ch;"
          onChange=${(e) => onChange({temperature:Number(e.target.value)})}/>
        `;
      }

      getTemperature(state) {
        return state.temperature;
      }
    }

    function Simulator({ state, setState }) {
      // We are going to generate one or more tables from our possible
      // configurations.  To do so, for now we'll have a fixed ordering of
      // options, so that the final tables will consist of the axes people are
      // most likely to care about.  You could imagine making this ordering
      // configurable at some point.
      const axisOrder = [
        'motor',
        'motorcooling',
        'cooling',
        'pwm',
        'voltage',
        'controller',
        'output',
      ];

      function handleClick(e, evalArgs, operatingPoint) {
        setState(prev => ({
          ...prev,
          overlay: {
            content: operatingPoint.render(),
            args: evalArgs,
          },
        }));
      }

      function handleOverlayDismiss(e) {
        setState(prev => ({
          ...prev,
          overlay: null,
        }));
      }

      function renderOverlay() {
        if (!state.overlay)  return null;
        const {content} = state.overlay;

        return html`
        <div onClick=${handleOverlayDismiss} class="overlayContainer">
          <div class="overlayBody"
            onClick=${e => e.stopPropagation()}
            >
            ${content}
            <button onClick=${handleOverlayDismiss}
                    style="margin-top: 1rem;">
              Close
            </button>
          </div>
        </div>
        `;
      }

      useEffect(() => {
        function handleDocumentClick(e) {
          const overlayBody = document.querySelector('.overlayBody');
          if (overlayBody && !overlayBody.contains(e.target)) {
            setState(prev => ({
              ...prev,
              overlay: null,

            }));
          }
        }

        if (state.overlay) {
          document.addEventListener('click', handleDocumentClick);
        }

        return () => {
          document.removeEventListener('click', handleDocumentClick);
        }
      }, [state.overlay, setState]);

      function evaluateAndRender(fixedPanels, variablePanels) {
        const getInstanceState = (name) => {
          const maybeFixed = fixedPanels?.[name] ?? null;
          if (maybeFixed != null) { return [maybeFixed[1], state.x[name][maybeFixed[0]]]; }
          // It must be variable.

          const panelName = name;
          const panelKey = variablePanels[panelName];
          const thisInstance = state[panelName][panelKey];
          const thisState = state.x[panelName][panelKey];
          return [thisInstance, thisState];
        };

        const [output, outputState] = getInstanceState("output");
        const [controller, controllerState] = getInstanceState("controller");
        const [voltage, voltageState] = getInstanceState("voltage");
        const [pwm, pwmState] = getInstanceState("pwm");
        const [cooling, coolingState] = getInstanceState("cooling");
        const [motor, motorState] = getInstanceState("motor");
        const [motorCooling, motorCoolingState] = getInstanceState("motorcooling");
        const evalArgs = {
          outputState: outputState,
          controller: controller, controllerState: controllerState,
          voltage: voltage, voltageState: voltageState,
          pwm: pwm, pwmState: pwmState,
          cooling: cooling, coolingState: coolingState,
          motor: motor, motorState: motorState,
          motorCooling: motorCooling, motorCoolingState: motorCoolingState,
          general: state.general, generalState: state.x.general,
        };
        const maybeRenderedValue = output.evaluate(evalArgs);
        const content = maybeRenderedValue?.render() ?? '';

        return html`<td
          onClick=${(e) => {
            e.stopPropagation();
            handleClick(e, evalArgs, maybeRenderedValue?.operatingPoint);
          }}
          style=${{
            cursor: 'pointer',
            background: shallowEqual(evalArgs, state.overlay?.args) ? '#eef' : 'transparent',
          }}
          >
          ${content}
        </td>`;
      }

      function generateTables(fixedPanels, unassignedPanelNames) {
        // If there are more than 2 unassigned instances that have more than
        // one selection, then we will need more than one table and will
        // iterate, concatenating the result.
        const singleSelections = unassignedPanelNames.filter((panelName) => {
          const panelState = state?.x?.[panelName] ?? null;
          return panelState == null ? false : panelState.selected.length == 1;
        });
        if (singleSelections.length > 0) {
          // We have at least one panel with only a single selection.  Assign
          // it an instance.
          const panelName = singleSelections[0];
          const panelKey = state.x[panelName].selected[0];
          const instance = state[panelName][panelKey];
          return generateTables({
            ...fixedPanels,
            [singleSelections[0]]: [panelKey, instance, false]
          },
            unassignedPanelNames.filter((name) => name != singleSelections[0]),
          );
        }
        const multipleSelections = unassignedPanelNames.filter((panelName) => {
          const panelState = state.x?.[panelName] ?? null;
          return panelState == null ? false : panelState.selected.length > 1;
        });
        if (multipleSelections.length > 2) {
          // If we have more than two panels that have multiple selections, we
          // will need to emit multiple tables consecutively.  Pick the
          // panel with multiple selections with the fewest selected items first.
          const firstName = multipleSelections.map(
                (panelName) => [state.x[panelName].selected.length, panelName]).sort()[0][1];

          // Now, iterate over all the selected instances of this name,
          // concatenating the result of recursing on ourself.
          const result = state.x[firstName].selected.map((key) => {
            const instance = state[firstName][key];
            return generateTables(
              {
                ...fixedPanels,
                // The third list element says this fixed parameter came from a
                // multiple selection, so we know to highlight it later.
                [firstName]: [key, instance, true],
              },
              unassignedPanelNames.filter((name) => name != firstName)
            )
          });
          const delimeter = result.reduce((acc, cur, index) => {
            if (index > 0) {
              acc.push(html`<hr/>`);
            }
            acc.push(cur);
            return acc;
          }, []);
          return delimeter;
        }
        // We have between 0 and 2 panels remaining with multiple selections.
        // That means we can generate either
        //  a) a single value
        //  b) a table with one row
        //  c) a table with >1 rows and 1> cols

        const header = html`
        <ul>${Object.entries(fixedPanels).map(([panelName, [panelKey,,isMultiple]]) =>
          html`<li class=${isMultiple ? "multiHeader" : null}>${state[panelName + "_displayName"]}: ${state[panelName][panelKey].displayName(state.x[panelName][panelKey])}</li>`
        )}</ul>`;

        let body = null;
        const required = ['output', 'controller', 'voltage', 'pwm', 'cooling', 'motorcooling'];
        const findPanel = (panel) => {
          if (fixedPanels?.[panel] != null) { return true; }
          if (multipleSelections.filter((key2) => key2 == panel).length > 0) { return true; }
          return false;
        };
        const any_missing = required.map((panel) => (findPanel(panel))).filter((value) => !value).length > 0;
        if (any_missing) {
          body = html`missing selection`;
        } else if (multipleSelections.length == 0) {
          body = html`
          <table>
            <tr>
              <td>
          ${fixedPanels.output[1].displayName(state.x.output[fixedPanels.output[0]])}
              </td>
              ${evaluateAndRender(fixedPanels, {})}
            </tr>
          </table>
          `;
        } else if (multipleSelections.length == 1) {
          const panelName = multipleSelections[0];
          const selectionKeys = state.x[panelName].selected;
          body = html`
            <table>
              <tr>
                <th scope="col">${state[multipleSelections[0] + "_displayName"]}</th>
                <th scope="col">
                  ${fixedPanels.output != null ? fixedPanels.output[1].displayName(state.x.output[fixedPanels.output[0]]) : ''}
                </th>
                <td></td>
              </tr>
              ${selectionKeys.map((key) => {
            return html`
                <tr>
              <td>${state[panelName][key].displayName(state.x[panelName][key])}</td>
                  ${evaluateAndRender(fixedPanels, { [panelName]: key })}
                </tr>
                `
          })}
            </table>
            `;
        } else if (multipleSelections.length == 2) {
          const sortedSelections = multipleSelections.map((name) => [axisOrder.indexOf(name), name]).slice().sort((a, b) => a[0] - b[0]).map(([, b]) => b);
          const panel1Name = sortedSelections[0];
          const panel2Name = sortedSelections[1];
          const selection1Keys = state.x[panel1Name].selected;
          const selection2Keys = state.x[panel2Name].selected;
          const panel1Instances = state[panel1Name];
          const panel2Instances = state[panel2Name];

          body = html`
            <table>
              <tr>
                <th></th>
                <th colspan="${selection2Keys.length}">${state[panel2Name + "_displayName"]}</th>
              </tr>
              <tr>
                <th scope="col">${state[panel1Name + "_displayName"]}</th>
                ${selection2Keys.map((key2) => html`<td>${panel2Instances[key2].displayName(state.x[panel2Name][key2])}</td>`)}
              </tr>
              ${selection1Keys.map((key1) => {
            return html`
                <tr>
              <td>${panel1Instances[key1].displayName(state.x[panel1Name][key1])}</td>
                  ${selection2Keys.map((key2) => {
              return html`
                      ${evaluateAndRender(fixedPanels, { [panel1Name]: key1, [panel2Name]: key2 })}
                    `;
            })}
                </tr>
                `;
          })}
            </table>
            `;
        }

        return html`
        <div class="resultTable" onClick=${e => e.stopPropagation()}>
          ${header}${body}
        </div>
        `;
      }

      return html`
        <div>
          ${generateTables({}, ['output', 'voltage', 'pwm', 'controller', 'cooling', 'motorcooling', 'motor'])}
          ${renderOverlay()}
        </div>
        `
    }

    function SelectorPanel({ panelName, displayName, onChange, instances, defaultChecked, checkable=true }) {
      const params = new URLSearchParams(window.location.search);

      const instanceKeys = instances.map((instance) => instance.name());

      const inputObject = parseDotNotationQuery(window.location.search);

      const myDefaultChecked =
        (args => args ? args.split(',')
          .filter((name) => instanceKeys.includes(name))
           : defaultChecked)(inputObject?.[panelName]);

      const defaultInstanceState = inputObject?.x?.[panelName] ?? {};

      const [state, setState] = useState(
        {
          ...instances.reduce((acc, item) => {
              acc[item.name()] = item.getState();
              return acc;
            }, {}),
          expanded: false,
          ...defaultInstanceState,
          selected: myDefaultChecked,
        }
      );

      const getSelectedInstances = (s) => {
        return instances.filter((item) => (s.selected.includes(item.name())));
      };

      useEffect(() => {
        onChange(panelName, displayName, getSelectedInstances(state), state);
      }, []);

      const toggleExpanded = () => {
        const newState = {
          ...state,
          expanded: !state.expanded,
        };
        setState(newState);
        onChange(panelName, displayName, getSelectedInstances(newState), newState);
      };

      const makeSummary = () => {
        const selectedItems = getSelectedInstances(state).map((item) => (item.displayName(state[item.name()])));
        return (selectedItems.length == 0) ?
          "- none" : "- [" + selectedItems.join(", ") + "]";
      };

      const sortByInstanceOrder = (keys) => {
        const instanceOrder = Object.fromEntries(instances.map((item, index) => [item.name(), index]));
        return keys.map((key) => [instanceOrder[key], key]).sort().map(([_, key]) => key);
      };

      const makeCheckToggle = (name) => {
        return () => {
          const isPresent = state.selected.includes(name);

          const newState = {
            ...state,
            'selected': isPresent ? state.selected.filter((k) => k != name) : sortByInstanceOrder([...state.selected, name]),
          };
          setState(newState);
          onChange(panelName, displayName, getSelectedInstances(newState), newState);
        }
      };

      const myOnChange = (panelName, displayName, selectedInstances, newState) => {
        setState(newState);
        onChange(panelName, displayName, selectedInstances, newState);
      };

      return html`
<div class="panelContainer">
  <div class="panelTitle" onClick=${() => toggleExpanded()} >
    ${displayName} ${!state.expanded ? makeSummary() : ""}
  </div>
  ${state.expanded && html`
  <fieldset>
  ${instances.map((item) => {
        return html`
    <label>
      ${checkable ? html`
      <input type="checkbox" name="${panelName}" value="${item.name()}"
        checked="${state.selected.includes(item.name()) ?? false}"
        onClick="${makeCheckToggle(item.name())}" />` : html``}
      ${item.render({}, state[item.name()],
           (newState) => myOnChange(
                  panelName,
                  displayName,
                  getSelectedInstances(state),
                  {
                    ...state,
                    [item.name()] : newState,
                  }))
        } ${item.productInfo()}
      <br/>
    </label>
    `;
      })}
  </fieldset>
`}
</div>
`;
    }


    const controllerOptions = [
      new MoteusC1(),
      new MoteusR4(),
      new MoteusN1(),
      new MoteusX1(),
      new OdriveMicro(),
      new OdriveS1(),
      new OdrivePro(),
    ];

    const voltageOptions = [
      new VoltageSelection(12),
      new VoltageSelection(24),
      new VoltageSelection(36),
      new VoltageSelection(48),
      new VoltageSelection(54),
    ];

    const pwmOptions = [
      new PwmSelection('default'),
      new PwmSelection(15000),
      new PwmSelection(20000),
      new PwmSelection(24000),
      new PwmSelection(30000),
      new PwmSelection(40000),
      new PwmSelection(60000),
    ];

    const coolingOptions = [
      new CoolingSelection('none', 'none'),
      new CoolingSelection('heatspreader', 'heat spreader'),
      new CoolingSelection('heatspreader_4030sink', 'heat spreader w/ 40x30mm sink'),
      new CoolingSelection('5vfan', '5V fan'),
      new CoolingSelection('12vfan', '12V fan'),
      new CoolingSelection('max', 'max'),
    ];

    const motorOptions = [
      new Motor({
        name:'mj5208',
        displayName:'mj5208',
        maxTemp:80,
        Kv: 304,
        R: 0.047,
        L:28.6e-6,
        mass_kg: 0.192,
        rotationCurrentCutoff: 22.5,
        rotationCurrentScale: 0.02494,
        rotationTorqueScale: 0.6638,
        thermal: {
          'none': {R: 4.55, C:164.59},  // measured 2025-04-18
          'max':  {R: 0.48, C:164.59},  // measured 2025-04-18 w/ large desk fan
        },
        // The following iron loss model was generated from pypowertrain and is
        // not from any measured or design values.
        d0: 0.002784,
        d1: 5.6668e-5,
        productInfo: html`<a href="https://mjbots.com/products/mj5208" target="_blank">[product page]</a>`,
      }),
      new Motor({
        name:'mad8318',
        displayName:'MAD 8318',
        maxTemp:80,
        Kv: 120,
        R: 0.015,
        L: 9.75e-6,
        mass_kg: 0.646,
        rotationCurrentCutoff: 31.07,
        rotationCurrentScale: 0.004214,
        rotationTorqueScale: 12.087,
        thermal: {
          'none': {R:1.516, C:735.5},  // measured 2025-04-17
          'max':  {R:0.311, C:735.5},  // measured 2025-04-18 w/ large desk fan
        },
        // The following iron loss model was generated from pypowertrain and is
        // not from any measured or design values.
        d0: 0.04132,
        d1: 0.002403,
        productInfo: html`<a href="https://mad-motor.com/products/mad-components-8318-ipe-for-agriculture-drone-motor" target="_blank">[product page]</a>`,
      }),
      new Motor({
        name:'gbm5208',
        displayName: 'Rctimer GBM5208',
        maxTemp: 80,
        Kv: 25.5,
        R: 7.545,
        L:2254.5e-6,
        mass_kg: 0.193,
        // We'll assume saturation isn't relevant for this gimbal wound motor.
        thermal: {
          // Assume this is basically the same as the mj5208, since mounting a
          // thermistor in the coils is basically impossible.
          'none': {R: 4.55, C:165},
          'max':  {R: 0.5,  C:165},
        },
        // The following iron loss model is roughly similar to the mj5208, as
        // the stators and magnets are somewhat similar.
        d0: 0.05,
        d1: 0.003,
        productInfo: html`<a href="https://www.rctimer.com/rctimer-gimbal-motor-gbm5208-75t-p0448.html" target="_blank">[product page]</a>`,
      }),
      new Motor({
        name:'hoverboard350',
        displayName: '8.5" 350W hoverboard',
        maxTemp: 80,
        Kv: 18.2,
        R: 0.216,
        L: 530e-6,
        mass_kg: 4.26,
        // No clue about saturation here yet, it is hard to mount in the dyno.
        thermal: {
          'none': {R: 2.41, C:946.1},
          'max': {R: 1.80, C:946.1},
        },
        // Totally arbitrary
        d0: 0.05,
        d1: 0.003,
        productInfo: html`<a href="https://gyroor.com/products/gyroor-warrior-hoverboard-adults" target="_blank">[product page]</a>`,
      }),
      new Motor({
        name:'ht1105',
        displayName: 'HT1105',
        maxTemp: 80,
        Kv: 1180,
        R: 6.435,
        L: 298.5e-6,
        mass_kg: 0.008,
        // i suspect due to its gimbal winding, no real saturation effects were
        // measured here all the way up to 2A of phase current.
        thermal: {
          'none': {R: 20.8, C:14.8},
          'max':  {R: 3.2,  C:14.8},
        },
        // Zero for now, as I have no clue.
        d0: 0.0,
        d1: 0.0,
        productInfo: html`<a href="https://www.aliexpress.us/item/3256805488283662.html" target="_blank">[product page]</a>`,
      }),
      new Motor({
        name:'be8108',
        displayName: 'be8108',
        maxTemp: 80,
        Kv: 135,
        R: 0.0910,
        L: 39.2e-6,
        rotationCurrentCutoff: 20.50,
        rotationCurrentScale: 0.04212,
        rotationTorqueScale: 1.0255,
        thermal: {
          'none': {R: 2.74, C:210.9},  // measured 2025-04-19
          'max':  {R: 0.27, C:210.9},  // measured 2025-04-19 w/ big desk fan
        },
        // Zero for now, as I have no clue.
        d0: 0.0,
        d1: 0.0,
        productInfo: html`<a href="https://hobbyking.com/en_us/dys-brushless-motor-86x10-be8108-12-135kv-for-multi-rotors.html" target="_blank">[product page]</a>`,
      }),
    ];

    const motorCoolingOptions = [
      new MotorCoolingSelection('none', 'none'),
      new MotorCoolingSelection('max', 'max'),
    ];

    const outputOptions = [
      new OutputContinuousCurrent('I_Infinity', Infinity),
      new OutputContinuousCurrent('I_60s', 60.0),
      new OutputContinuousCurrent('I_2s', 2.0),
      new OutputMotorTorque('T_Infinity', Infinity, 0),
      new OutputMotorTorque('T_60s', 60.0, 0),
      new OutputMotorTorque('T_2s', 2.0, 0),
      new OutputMotorTorque('T_Infinity_10', Infinity, 40),
      new OutputMotorTorqueConfigurable('T_X'),
      new OutputMaxPower(),
      new OutputMotorMaxSpeed(),
    ];

    const generalOptions = [
      new AmbientTemperature(),
    ];

    function App() {
      const [state, setState] = useState({});

      const onChange = (panelName, displayName, selectedInstances, panelState) => {
        setState(prev => ({
          ...prev,
          x:{
            ...prev.x,
            [panelName]:panelState,
          },
          [panelName + "_displayName"]: displayName,
          [panelName]: Object.fromEntries(selectedInstances.map((item) => [item.name(), item])),
        }));
      };

      const isSavedState = (keyvalue) => {
        const [key, value] = keyvalue.split('=');
        if (key.split('.')[1] == "selected") { return false; }
        if (key.split('.')[1] == "expanded" && value == "false") { return false; }
        return true;
      };

      const makeShareLink = () => {
        return window.location.href.split('?')[0] +
               "?" +
               [...Object.entries(state?.x ?? {})
                  .map(([panel, obj]) => panel + "=" + (obj?.selected ?? []).join(",")),
                ...objectToDotNotationParams(state?.x ?? null)
                    .filter((item) => isSavedState(item))
                    .map((item) => 'x.' + item)]
                  .join("&");
      };

      const copyShareLink = (event) => {
        event.preventDefault();
        const shareLink = makeShareLink();
        navigator.clipboard.writeText(shareLink);
      };

      return html`
<div class="pageContainer">
  <div class="panelColumn">
<${SelectorPanel} panelName="controller" displayName="Controller" onChange=${onChange} instances=${controllerOptions} defaultChecked=${['moteus-c1','moteus-r4','moteus-n1','moteus-x1']}/>
<${SelectorPanel} panelName="voltage" displayName="Supply Voltage" onChange=${onChange} instances=${voltageOptions} defaultChecked=${['12V', '24V', '36V', '48V', '54V']}/>
<${SelectorPanel} panelName="pwm" displayName="PWM Frequency" onChange=${onChange} instances=${pwmOptions} defaultChecked=${['default']}/>
<${SelectorPanel} panelName="cooling" displayName="Controller Cooling" onChange=${onChange} instances=${coolingOptions} defaultChecked=${['none']}/>
<${SelectorPanel} panelName="motor" displayName="Motor" onChange=${onChange} instances=${motorOptions} defaultChecked=${['mad8318']}/>
<${SelectorPanel} panelName="motorcooling" displayName="Motor Cooling" onChange=${onChange} instances=${motorCoolingOptions} defaultChecked=${['none']}/>
<${SelectorPanel} panelName="output" displayName="Output" onChange=${onChange} instances=${outputOptions} defaultChecked=${['T_Infinity']}/>
<${SelectorPanel} panelName="general" displayName="General" onChange=${onChange} instances=${generalOptions} defaultChecked=${['ambient']} checkable=${false}/>
<a href="${makeShareLink()}" onclick=${copyShareLink}>Copy link to these results...</a>
  </div>
  <div class="resultsColumn">
<${Simulator} state=${state} setState=${setState}/>
  </div>
</div>
<footer>
  <div>
    moteus Performance Analysis Tool
    | Licensed under Apache 2.0
    | (c) 2025 mjbots Robotic Systems
    | <a href="https://mjbots.com">https://mjbots.com</a>
  </div>
</footer>
                `;
    }

    render(html`<${App}/>`, document.body);
  </script>
</head>

</html>